{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "369c3444",
   "metadata": {},
   "source": [
    "# IMDB Vector Search using Milvus Client"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f6ffd11a",
   "metadata": {},
   "source": [
    "First, import some common libraries and define the data reading functions."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "d7570b2e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# For colab install these libraries in this order:\n",
    "# !pip install pymilvus, langchain, torch, transformers, python-dotenv\n",
    "\n",
    "# Import common libraries.\n",
    "import sys, time, pprint\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "# Import custom functions for splitting and search.\n",
    "sys.path.append(\"..\")  # Adds higher directory to python modules path.\n",
    "import milvus_utilities as _utils"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fb844837",
   "metadata": {},
   "source": [
    "## Start up a Zilliz free tier cluster.\n",
    "\n",
    "Code in this notebook uses fully-managed Milvus on [Ziliz Cloud free trial](https://cloud.zilliz.com/login).  \n",
    "  1. Choose the default \"Starter\" option when you provision > Create collection > Give it a name > Create cluster and collection.  \n",
    "  2. On the Cluster main page, copy your `API Key` and store it locally in a .env variable.  See note below how to do that.\n",
    "  3. Also on the Cluster main page, copy the `Public Endpoint URI`.\n",
    "\n",
    "💡 Note: To keep your tokens private, best practice is to use an **env variable**.  See [how to save api key in env variable](https://help.openai.com/en/articles/5112595-best-practices-for-api-key-safety). <br>\n",
    "\n",
    "In Jupyter, you also need a .env file (in same dir as notebooks) containing lines like this:\n",
    "- VARIABLE_NAME=value\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "0806d2db",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Type of server: zilliz_cloud\n"
     ]
    }
   ],
   "source": [
    "# STEP 1. CONNECT TO MILVUS\n",
    "\n",
    "# !pip install pymilvus #python sdk for milvus\n",
    "from pymilvus import connections, utility\n",
    "\n",
    "import os\n",
    "from dotenv import load_dotenv\n",
    "load_dotenv()\n",
    "TOKEN = os.getenv(\"ZILLIZ_API_KEY\")\n",
    "\n",
    "# Connect to Zilliz cloud using endpoint URI and API key TOKEN.\n",
    "# TODO change this.\n",
    "CLUSTER_ENDPOINT=\"https://in03-xxxx.api.gcp-us-west1.zillizcloud.com:443\"\n",
    "connections.connect(\n",
    "  alias='default',\n",
    "  #  Public endpoint obtained from Zilliz Cloud\n",
    "  uri=CLUSTER_ENDPOINT,\n",
    "  # API key or a colon-separated cluster username and password\n",
    "  token=TOKEN,\n",
    ")\n",
    "\n",
    "# Check if the server is ready and get colleciton name.\n",
    "print(f\"Type of server: {utility.get_server_version()}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b01d6622",
   "metadata": {},
   "source": [
    "## Load the Embedding Model checkpoint and use it to create vector embeddings\n",
    "**Embedding model:**  We will use the open-source [sentence transformers](https://www.sbert.net/docs/pretrained_models.html) available on HuggingFace to encode the documentation text.  We will download the model from HuggingFace and run it locally. \n",
    "\n",
    "Two model parameters of note below:\n",
    "1. EMBEDDING_LENGTH refers to the dimensionality or length of the embedding vector. In this case, the embeddings generated for EACH token in the input text will have the SAME length = 1024. This size of embedding is often associated with BERT-based models, where the embeddings are used for downstream tasks such as classification, question answering, or text generation. <br><br>\n",
    "2. MAX_SEQ_LENGTH is the maximum length the encoder model can handle for input sequences. In this case, if sequences longer than 512 tokens are given to the model, everything longer will be (silently!) chopped off.  This is the reason why a chunking strategy is needed to segment input texts into chunks with lengths that will fit in the model's input."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "dd2be7fd",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "device: cpu\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "No sentence-transformers model found with name /Users/christybergman/.cache/torch/sentence_transformers/WhereIsAI_UAE-Large-V1. Creating a new one with MEAN pooling.\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "<class 'sentence_transformers.SentenceTransformer.SentenceTransformer'>\n",
      "SentenceTransformer(\n",
      "  (0): Transformer({'max_seq_length': 512, 'do_lower_case': False}) with Transformer model: BertModel \n",
      "  (1): Pooling({'word_embedding_dimension': 1024, 'pooling_mode_cls_token': False, 'pooling_mode_mean_tokens': True, 'pooling_mode_max_tokens': False, 'pooling_mode_mean_sqrt_len_tokens': False})\n",
      ")\n",
      "model_name: WhereIsAI/UAE-Large-V1\n",
      "EMBEDDING_LENGTH: 1024\n",
      "MAX_SEQ_LENGTH: 512\n"
     ]
    }
   ],
   "source": [
    "# STEP 2. DOWNLOAD AN OPEN SOURCE EMBEDDING MODEL.\n",
    "\n",
    "# Import torch.\n",
    "import torch\n",
    "from torch.nn import functional as F\n",
    "from sentence_transformers import SentenceTransformer\n",
    "\n",
    "# Initialize torch settings\n",
    "torch.backends.cudnn.deterministic = True\n",
    "DEVICE = torch.device('cuda:3' if torch.cuda.is_available() else 'cpu')\n",
    "print(f\"device: {DEVICE}\")\n",
    "\n",
    "# Load the model from huggingface model hub.\n",
    "# python -m pip install -U angle-emb\n",
    "model_name = \"WhereIsAI/UAE-Large-V1\"\n",
    "encoder = SentenceTransformer(model_name, device=DEVICE)\n",
    "print(type(encoder))\n",
    "print(encoder)\n",
    "\n",
    "# Get the model parameters and save for later.\n",
    "EMBEDDING_LENGTH = encoder.get_sentence_embedding_dimension()\n",
    "MAX_SEQ_LENGTH_IN_TOKENS = encoder.get_max_seq_length() \n",
    "# # Assume tokens are 3 characters long.\n",
    "# MAX_SEQ_LENGTH = MAX_SEQ_LENGTH_IN_TOKENS * 3\n",
    "# HF_EOS_TOKEN_LENGTH = 1 * 3\n",
    "# Test with 512 sequence length.\n",
    "MAX_SEQ_LENGTH = MAX_SEQ_LENGTH_IN_TOKENS\n",
    "HF_EOS_TOKEN_LENGTH = 1\n",
    "\n",
    "# Inspect model parameters.\n",
    "print(f\"model_name: {model_name}\")\n",
    "print(f\"EMBEDDING_LENGTH: {EMBEDDING_LENGTH}\")\n",
    "print(f\"MAX_SEQ_LENGTH: {MAX_SEQ_LENGTH}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2b12728",
   "metadata": {},
   "source": [
    "## Create a Milvus collection\n",
    "\n",
    "You can think of a collection in Milvus like a \"table\" in SQL databases.  The **collection** will contain the \n",
    "- **Schema** (or [no-schema Milvus client](https://milvus.io/docs/using_milvusclient.md)).  \n",
    "💡 You'll need the vector `EMBEDDING_LENGTH` parameter from your embedding model.\n",
    "Typical values are:\n",
    "   - 768 for sbert embedding models\n",
    "   - 1536 for ada-002 OpenAI embedding models\n",
    "- **Vector index** for efficient vector search\n",
    "- **Vector distance metric** for measuring nearest neighbor vectors\n",
    "- **Consistency level**\n",
    "In Milvus, transactional consistency is possible; however, according to the [CAP theorem](https://en.wikipedia.org/wiki/CAP_theorem), some latency must be sacrificed. 💡 Searching movie reviews is not mission-critical, so [`eventually`](https://milvus.io/docs/consistency.md) consistent is fine here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "87a34e05",
   "metadata": {},
   "source": [
    "### Exercise #1 (2 min):\n",
    "Create a collection named \"movies\".  Use the default AUTOINDEX.\n",
    "> 💡 AUTOINDEX works on both Milvus and Zilliz Cloud (where it is the fastest!)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20a05d59",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pymilvus import MilvusClient\n",
    "\n",
    "# Set the Milvus collection name.\n",
    "COLLECTION_NAME = # TODO (exercise): code here\n",
    "\n",
    "# Use no-schema Milvus client uses flexible json key:value format.\n",
    "# https://milvus.io/docs/using_milvusclient.md\n",
    "mc = MilvusClient(\n",
    "    uri=CLUSTER_ENDPOINT,\n",
    "    # API key or a colon-separated cluster username and password\n",
    "    token=TOKEN)\n",
    "\n",
    "mc.drop_collection(COLLECTION_NAME)\n",
    "mc.create_collection(COLLECTION_NAME, \n",
    "                     EMBEDDING_LENGTH, \n",
    "                    )\n",
    "\n",
    "print(mc.describe_collection(COLLECTION_NAME))\n",
    "print(f\"Created collection: {COLLECTION_NAME}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Add a Vector Index\n",
    "\n",
    "The vector index determines the vector **search algorithm** used to find the closest vectors in your data to the query a user submits.  \n",
    "\n",
    "Most vector indexes use different sets of parameters depending on whether the database is:\n",
    "- **inserting vectors** (creation mode) - vs - \n",
    "- **searching vectors** (search mode) \n",
    "\n",
    "Scroll down the [docs page](https://milvus.io/docs/index.md) to see a table listing different vector indexes available on Milvus.  For example:\n",
    "- FLAT - deterministic exhaustive search\n",
    "- IVF_FLAT or IVF_SQ8 - Hash index (stochastic approximate search)\n",
    "- HNSW - Graph index (stochastic approximate search)\n",
    "- AUTOINDEX - Automatically determined based on OSS vs [Zilliz cloud](https://docs.zilliz.com/docs/autoindex-explained), type of GPU, size of data.\n",
    "\n",
    "Besides a search algorithm, we also need to specify a **distance metric**, that is, a definition of what is considered \"close\" in vector space.  In the cell below, the [`HNSW`](https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md) search index is chosen.  Its possible distance metrics are one of:\n",
    "- L2 - L2-norm\n",
    "- IP - Dot-product\n",
    "- COSINE - Angular distance\n",
    "\n",
    "💡 Most use cases work better with normalized embeddings, in which case L2 is useless (every vector has length=1) and IP and COSINE are the same.  Only choose L2 if you plan to keep your embeddings unnormalized."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "4a85b295",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Embedding length: 1024\n",
      "Successfully dropped collection: `movies`\n",
      "Created collection: movies\n",
      "{'collection_name': 'movies', 'auto_id': True, 'num_shards': 1, 'description': '', 'fields': [{'field_id': 100, 'name': 'id', 'description': '', 'type': 5, 'params': {}, 'element_type': 0, 'auto_id': True, 'is_primary': True}, {'field_id': 101, 'name': 'vector', 'description': '', 'type': 101, 'params': {'dim': 1024}, 'element_type': 0}], 'aliases': [], 'collection_id': 446268198622108304, 'consistency_level': 3, 'properties': {}, 'num_partitions': 1, 'enable_dynamic_field': True}\n"
     ]
    }
   ],
   "source": [
    "# STEP 3. CREATE A NO-SCHEMA MILVUS COLLECTION AND DEFINE THE DATABASE INDEX.\n",
    "\n",
    "# Re-run create collection and add vector index specifying custom params.\n",
    "from pymilvus import MilvusClient\n",
    "\n",
    "# For vector length, use the embedding length from the embedding model.\n",
    "print(f\"Embedding length: {EMBEDDING_LENGTH}\")\n",
    "\n",
    "# Set the Milvus collection name.\n",
    "COLLECTION_NAME = \"movies\"\n",
    "\n",
    "# Add custom HNSW search index to the collection.\n",
    "# M = max number graph connections per layer. Large M = denser graph.\n",
    "# Choice of M: 4~64, larger M for larger data and larger embedding lengths.\n",
    "M = 16\n",
    "# efConstruction = num_candidate_nearest_neighbors per layer. \n",
    "# Use Rule of thumb: int. 8~512, efConstruction = M * 2.\n",
    "efConstruction = M * 2\n",
    "# Create the search index for local Milvus server.\n",
    "INDEX_PARAMS = dict({\n",
    "    'M': M,               \n",
    "    \"efConstruction\": efConstruction })\n",
    "index_params = {\n",
    "    \"index_type\": \"HNSW\", \n",
    "    \"metric_type\": \"COSINE\", \n",
    "    \"params\": INDEX_PARAMS\n",
    "    }\n",
    "\n",
    "# Use no-schema Milvus client (flexible json key:value format).\n",
    "# https://milvus.io/docs/using_milvusclient.md\n",
    "mc = MilvusClient(\n",
    "    uri=CLUSTER_ENDPOINT,\n",
    "    # API key or a colon-separated cluster username and password\n",
    "    token=TOKEN)\n",
    "\n",
    "# Check if collection already exists, if so drop it.\n",
    "has = utility.has_collection(COLLECTION_NAME)\n",
    "if has:\n",
    "    drop_result = utility.drop_collection(COLLECTION_NAME)\n",
    "    print(f\"Successfully dropped collection: `{COLLECTION_NAME}`\")\n",
    "\n",
    "mc.create_collection(\n",
    "    COLLECTION_NAME, \n",
    "    EMBEDDING_LENGTH, \n",
    "    consistency_level=\"Eventually\", \n",
    "    auto_id=True,  \n",
    "    overwrite=True,\n",
    "    # skip setting params below, if using AUTOINDEX\n",
    "    params=index_params\n",
    "    )\n",
    "\n",
    "print(f\"Created collection: {COLLECTION_NAME}\")\n",
    "print(mc.describe_collection(COLLECTION_NAME))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e735fe08",
   "metadata": {},
   "source": [
    "## Read CSV data into a pandas dataframe\n",
    "\n",
    "The data used in this notebook is the [IMDB large movie review dataset](https://ai.stanford.edu/~amaas/data/sentiment/) from the Stanford AI Lab. It is a conveniently processed 50,000 dataset (50:50 sampled ratio Positive/Negative reviews). This data has columns: movie_index, raw review text, and movie rating."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "6861beb7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 1. Download data from https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz\n",
    "# 2. Move .csv file to data/ folder.\n",
    "\n",
    "# citation:  ACL 2011, @InProceedings{maas-EtAl:2011:ACL-HLT2011,\n",
    "#   author    = {Maas, Andrew L.  and  Daly, Raymond E.  and  Pham, Peter T.  and  Huang, Dan  and  Ng, Andrew Y.  and  Potts, Christopher},\n",
    "#   title     = {Learning Word Vectors for Sentiment Analysis},\n",
    "#   booktitle = {Proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies},\n",
    "#   month     = {June},\n",
    "#   year      = {2011},\n",
    "#   address   = {Portland, Oregon, USA},\n",
    "#   publisher = {Association for Computational Linguistics},\n",
    "#   pages     = {142--150},\n",
    "#   url       = {http://www.aclweb.org/anthology/P11-1015}\n",
    "# }"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "6a381e57",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "original df shape: (100, 4)\n",
      "df_train shape: (100, 4), df_val shape: (0, 4), df_test shape: (0, 4)\n",
      "Example text length: 1113\n",
      "Example text: The whole town of Blackstone is afraid, because they lynched Bret Dixon's brother - and he is coming back for revenge! At least that's what they think.<br /><br />A great Johnny Hallyday and a very interesting, early Mario Adorf star in this Italo-Western, obviously filmed in the Alps.<br /><br />Bret Dixon is coming back to Blackstone to investigate why his brother was lynched. He is a loner and gunslinger par excellance, everybody is afraid of him - the Mexican bandits (fighting the Gringos that took their land!) as well as the \"decent\" citizens that lynched Bret's brother. They lynched him, because they thought he stole their money instead of bringing it to Dallas to the safety of the bank there. But this is is only half the truth, as we find out in the course of this psychologically interesting western.<br /><br />But beware, it's kind of a depressing movie as everybody turns out to be guilty somehow and definitely everybody is bad to the bone...<br /><br />Still, I enjoyed it very much and gave it an 8/10. Strange, that only less than 5 people voted for this movie as of January 12th 2002....\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>movie_index</th>\n",
       "      <th>text</th>\n",
       "      <th>label_int</th>\n",
       "      <th>label</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>80</td>\n",
       "      <td>The whole town of Blackstone is afraid, becaus...</td>\n",
       "      <td>1</td>\n",
       "      <td>Positive</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>84</td>\n",
       "      <td>This Harold Lloyd short wasn't really much; no...</td>\n",
       "      <td>0</td>\n",
       "      <td>Negative</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   movie_index                                               text  label_int  \\\n",
       "0           80  The whole town of Blackstone is afraid, becaus...          1   \n",
       "1           84  This Harold Lloyd short wasn't really much; no...          0   \n",
       "\n",
       "      label  \n",
       "0  Positive  \n",
       "1  Negative  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Read locally stored data.\n",
    "filepath = \"data/movie_data.csv\"\n",
    "\n",
    "df = pd.read_csv(f\"{filepath}\")\n",
    "\n",
    "# Drop duplicates\n",
    "df.drop_duplicates(keep='first', inplace=True)\n",
    "\n",
    "# Change label column names.\n",
    "df.columns = ['text', 'label_int']\n",
    "\n",
    "# Map numbers to text 'Postive' and 'Negative' for sentiment labels.\n",
    "df[\"label\"] = df[\"label_int\"].apply(_utils.sentiment_score_to_name)\n",
    "\n",
    "# Split data into train/valid/test.\n",
    "columns = ['movie_index', 'text', 'label_int', 'label']\n",
    "df, df_train, df_val, df_test = _utils.partition_dataset(df, columns, smoke_test=False)\n",
    "print(f\"original df shape: {df.shape}\")\n",
    "print(f\"df_train shape: {df_train.shape}, df_val shape: {df_val.shape}, df_test shape: {df_test.shape}\")\n",
    "assert df_train.shape[0] + df_val.shape[0] + df_test.shape[0] == df.shape[0]\n",
    "\n",
    "# Inspect data.\n",
    "print(f\"Example text length: {len(df.text[0])}\")\n",
    "print(f\"Example text: {df.text[0]}\")\n",
    "display(df.head(2))\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "654dd135",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Count samples positive: 50\n",
      "Count samples negative: 50\n"
     ]
    }
   ],
   "source": [
    "# Check if approx. equal number training examples for each class.\n",
    "class1 = df_train.loc[(df_train.label == \"Positive\"), :].copy()\n",
    "class2 = df_train.loc[(df_train.label == \"Negative\"), :].copy()\n",
    "print(f\"Count samples positive: {class1.shape[0]}\")\n",
    "print(f\"Count samples negative: {class2.shape[0]}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Uncomment this to create the small sample of data for github.\n",
    "# df_small = df.head(100)[['text', 'label_int']].copy()\n",
    "# display(df_small.head())\n",
    "# df_small.to_csv(\"data/movie_data_small.csv\", index=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c60423a5",
   "metadata": {},
   "source": [
    "## Chunking\n",
    "\n",
    "Before embedding, it is necessary to decide your chunk strategy, chunk size, and chunk overlap.  In this demo, I will use:\n",
    "- **Strategy** = Keep movie reveiws as single chunks unless they are too long.\n",
    "- **Chunk size** = Use the embedding model's parameter `MAX_SEQ_LENGTH`\n",
    "- **Overlap** = Rule-of-thumb 10-15%\n",
    "- **Function** = Langchain's convenient `RecursiveCharacterTextSplitter` to split up long reviews recursively.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "249e9c74",
   "metadata": {},
   "source": [
    "⚠️ **Demo batch size = 100 rows for demonstration purposes.**\n",
    "\n",
    "This means the question results could be better with more data!"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8c1b0045",
   "metadata": {},
   "source": [
    "### Exercise #2 (2 min):\n",
    "Change the chunk_size and see what happens?  Model default is 511.\n",
    "\n",
    "- What do your observations imply about changing the chunk_size and the number of vectors?\n",
    "- How many vectors are there with chunk_size=256?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1954c96d",
   "metadata": {},
   "outputs": [],
   "source": [
    "###############\n",
    "## EXERCISE #1: Change chunk_size to 256 below.  How many chunks (vectors) does this create?\n",
    "## ANSWER:  542\n",
    "## BONUS:   Can you explain why the number of vectors changed from 290 to 542?  \n",
    "##          Hint:  What is the default chunk overlap?  290 * (2 - 0.10) approx. equals 542.\n",
    "###############\n",
    "# Default chunk_size and overlap are calculated from embedding model parameters.\n",
    "chunk_size = # TODO (exercise): code here\n",
    "chunk_overlap = np.round(chunk_size * 0.10, 0)\n",
    "BATCH_SIZE = 100\n",
    "\n",
    "# Chunk a batch of data from pandas DataFrame and inspect it.\n",
    "batch = _utils.imdb_chunk_text( # TODO (exercise): code here )"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "470a93c7",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "chunk size: 511\n",
      "original shape: (100, 4)\n",
      "new shape: (290, 5)\n",
      "Chunking + embedding time for 100 docs: 20.689467906951904 sec\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>movie_index</th>\n",
       "      <th>text</th>\n",
       "      <th>chunk</th>\n",
       "      <th>vector</th>\n",
       "      <th>label_int</th>\n",
       "      <th>label</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>80</td>\n",
       "      <td>The whole town of Blackstone is afraid, becaus...</td>\n",
       "      <td>The whole town of Blackstone is afraid, becaus...</td>\n",
       "      <td>[0.023260135, 0.03262592, 0.0071149827, 0.0475...</td>\n",
       "      <td>1</td>\n",
       "      <td>Positive</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>80</td>\n",
       "      <td>The whole town of Blackstone is afraid, becaus...</td>\n",
       "      <td>Mexican bandits (fighting the Gringos that too...</td>\n",
       "      <td>[0.024261247, 0.018350782, -0.005168957, 0.020...</td>\n",
       "      <td>1</td>\n",
       "      <td>Positive</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>80</td>\n",
       "      <td>The whole town of Blackstone is afraid, becaus...</td>\n",
       "      <td>and definitely everybody is bad to the bone......</td>\n",
       "      <td>[0.034700453, 0.011013481, -0.022261137, 0.003...</td>\n",
       "      <td>1</td>\n",
       "      <td>Positive</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>84</td>\n",
       "      <td>This Harold Lloyd short wasn't really much; no...</td>\n",
       "      <td>This Harold Lloyd short wasn't really much; no...</td>\n",
       "      <td>[0.01173156, 0.01819113, 0.03528512, 0.0179632...</td>\n",
       "      <td>0</td>\n",
       "      <td>Negative</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>84</td>\n",
       "      <td>This Harold Lloyd short wasn't really much; no...</td>\n",
       "      <td>part was the last four or five minutes when th...</td>\n",
       "      <td>[0.05225119, 0.033677388, 0.011586295, 0.00569...</td>\n",
       "      <td>0</td>\n",
       "      <td>Negative</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "  movie_index                                               text  \\\n",
       "0          80  The whole town of Blackstone is afraid, becaus...   \n",
       "1          80  The whole town of Blackstone is afraid, becaus...   \n",
       "2          80  The whole town of Blackstone is afraid, becaus...   \n",
       "3          84  This Harold Lloyd short wasn't really much; no...   \n",
       "4          84  This Harold Lloyd short wasn't really much; no...   \n",
       "\n",
       "                                               chunk  \\\n",
       "0  The whole town of Blackstone is afraid, becaus...   \n",
       "1  Mexican bandits (fighting the Gringos that too...   \n",
       "2  and definitely everybody is bad to the bone......   \n",
       "3  This Harold Lloyd short wasn't really much; no...   \n",
       "4  part was the last four or five minutes when th...   \n",
       "\n",
       "                                              vector  label_int     label  \n",
       "0  [0.023260135, 0.03262592, 0.0071149827, 0.0475...          1  Positive  \n",
       "1  [0.024261247, 0.018350782, -0.005168957, 0.020...          1  Positive  \n",
       "2  [0.034700453, 0.011013481, -0.022261137, 0.003...          1  Positive  \n",
       "3  [0.01173156, 0.01819113, 0.03528512, 0.0179632...          0  Negative  \n",
       "4  [0.05225119, 0.033677388, 0.011586295, 0.00569...          0  Negative  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "type embeddings: <class 'pandas.core.series.Series'> of <class 'numpy.ndarray'>\n",
      "of numbers: <class 'numpy.float32'>\n"
     ]
    }
   ],
   "source": [
    "# Don't forget to re-run using the better batch size!  \n",
    "\n",
    "# Use the embedding model parameters to calculate chunk_size and overlap.\n",
    "chunk_size = MAX_SEQ_LENGTH - HF_EOS_TOKEN_LENGTH\n",
    "chunk_overlap = np.round(chunk_size * 0.10, 0)\n",
    "BATCH_SIZE = 100\n",
    "\n",
    "# Chunk a batch of data from pandas DataFrame and inspect it.\n",
    "batch = _utils.imdb_chunk_text(encoder, BATCH_SIZE, df, chunk_size, chunk_overlap)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d9bd8153",
   "metadata": {},
   "source": [
    "## Insert data into Milvus\n",
    "\n",
    "For each original text chunk, we'll write the quadruplet (`vector, text, source, h1, h2`) into the database.\n",
    "\n",
    "<div>\n",
    "<img src=\"../../images/db_insert.png\" width=\"80%\"/>\n",
    "</div>\n",
    "\n",
    "**The Milvus Client wrapper can only handle loading data from a list of dictionaries.**\n",
    "\n",
    "Otherwise, in general, Milvus supports loading data from:\n",
    "- pandas dataframes \n",
    "- list of dictionaries\n",
    "\n",
    "Below, we use the embedding model provided by HuggingFace, download its checkpoint, and run it locally as the encoder."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "b51ff139",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Start inserting entities\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 1/1 [00:05<00:00,  5.90s/it]\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Milvus insert time for 290 vectors: 5.9037556648254395 seconds\n"
     ]
    }
   ],
   "source": [
    "# STEP 5. INSERT CHUNKS AND EMBEDDINGS IN ZILLIZ.\n",
    "\n",
    "# Convert DataFrame to a list of dictionaries\n",
    "dict_list = []\n",
    "for _, row in batch.iterrows():\n",
    "    dictionary = row.to_dict()\n",
    "    dict_list.append(dictionary)\n",
    "\n",
    "print(\"Start inserting entities\")\n",
    "start_time = time.time()\n",
    "insert_result = mc.insert(\n",
    "    COLLECTION_NAME,\n",
    "    data=dict_list,\n",
    "    progress_bar=True)\n",
    "end_time = time.time()\n",
    "print(f\"Milvus insert time for {batch.shape[0]} vectors: {end_time - start_time} seconds\")\n",
    "\n",
    "# After final entity is inserted, call flush to stop growing segments left in memory.\n",
    "mc.flush(COLLECTION_NAME)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4ebfb115",
   "metadata": {},
   "source": [
    "## Run a Semantic Search\n",
    "\n",
    "Now we can run very fast search over all the movie review embeddings to find the `TOP_K` movie reviews with the closest embeddings to a user's query.\n",
    "- In this example, we'll search for a movie recommendation for a medical doctor.\n",
    "\n",
    "💡 The same model should always be used for consistency for all the embeddings."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "02c589ff",
   "metadata": {},
   "source": [
    "## Ask a question about your data\n",
    "\n",
    "So far in this demo notebook: \n",
    "1. Your custom data has been mapped into a vector embedding space\n",
    "2. Those vector embeddings have been saved into a vector database\n",
    "\n",
    "Next, you can ask a question about your custom data!\n",
    "\n",
    "💡 With LLMs:\n",
    "> **Query** is the generic term for user questions.  \n",
    "A query is a list of multiple individual questions, up to maybe 1000 different questions!\n",
    "\n",
    "> **Question** usually refers to a single user question.  \n",
    "In our example below, the user question is \"I'm a medical doctor, what movie should I watch?\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "5e7f41f4",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "query length: 48\n"
     ]
    }
   ],
   "source": [
    "# Define a sample question about your data.\n",
    "question = \"I'm a medical doctor, what movie should I watch?\"\n",
    "query = [question]\n",
    "\n",
    "# Inspect the length of the query.\n",
    "QUERY_LENGTH = len(query[0])\n",
    "print(f\"query length: {QUERY_LENGTH}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fa545611",
   "metadata": {},
   "source": [
    "**Embed the question using the same embedding model you used earlier**\n",
    "\n",
    "In order for vector search to work, the question itself should be embedded with the same model used to create the colleciton you want to search."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "a6863a32",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "<class 'list'> 1 <class 'numpy.ndarray'>\n",
      "<class 'numpy.float32'>\n"
     ]
    }
   ],
   "source": [
    "# Embed the query using same embedding model used to create the Milvus collection.\n",
    "query_embeddings = _utils.embed_query(encoder, query)\n",
    "\n",
    "# Inspect data.\n",
    "print(type(query_embeddings), len(query_embeddings), type(query_embeddings[0]))\n",
    "print(type(query_embeddings[0][0]) ) "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9ea29411",
   "metadata": {},
   "source": [
    "## Execute a vector search\n",
    "\n",
    "Search Milvus using [PyMilvus API](https://milvus.io/docs/search.md).\n",
    "\n",
    "💡 By their nature, vector searches are \"semantic\" searches.  For example, if you were to search for \"leaky faucet\": \n",
    "> **Traditional Key-word Search** - either or both words \"leaky\", \"faucet\" would have to match some text in order to return a web page or link text to the document.\n",
    "\n",
    "> **Semantic search** - results containing words \"drippy\" \"taps\" would be returned as well because these words mean the same thing even though they are different words,"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e49e830c",
   "metadata": {},
   "source": [
    "### Exercise #3 (2 min):\n",
    "Search Milvus using the default search index.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "2ace8d04",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Search time: 0.17416715621948242 sec\n",
      "type: <class 'list'>, count: 10\n"
     ]
    }
   ],
   "source": [
    "# Run semantic vector search using your query and the vector database.\n",
    "\n",
    "# # Not needed with Milvus Client API.\n",
    "# mc.load()\n",
    "\n",
    "# Uses default search algorithm:  HNSW and top_k=10.\n",
    "start_time = time.time()\n",
    "results = mc.search(\n",
    "    COLLECTION_NAME,\n",
    "    data=query_embeddings, \n",
    "    )\n",
    "\n",
    "elapsed_time = time.time() - start_time\n",
    "print(f\"Search time: {elapsed_time} sec\")\n",
    "\n",
    "# Inspect search result.\n",
    "print(f\"type: {type(results)}, count: {len(results[0])}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "c5d98e28",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Milvus search time: 0.0590059757232666 sec\n",
      "type: <class 'list'>, count: 3\n"
     ]
    }
   ],
   "source": [
    "# Re-run the search using custom settings.\n",
    "\n",
    "# Return top k results with HNSW index.\n",
    "TOP_K = 3\n",
    "OUTPUT_FIELDS=[\"movie_index\", \"chunk\", \"label\"]\n",
    "SEARCH_PARAMS = dict({\n",
    "    # Re-use index param for num_candidate_nearest_neighbors.\n",
    "    \"ef\": INDEX_PARAMS['efConstruction']\n",
    "    })\n",
    "\n",
    "# Run the search and time it.\n",
    "start_time = time.time()\n",
    "results = mc.search(\n",
    "    COLLECTION_NAME,\n",
    "    data=query_embeddings, \n",
    "    search_params=SEARCH_PARAMS,\n",
    "    output_fields=OUTPUT_FIELDS, \n",
    "    # Milvus can utilize metadata in boolean expressions to filter search.\n",
    "    # expr=\"\",\n",
    "    limit=TOP_K,\n",
    "    consistency_level=\"Eventually\",\n",
    "    )\n",
    "\n",
    "elapsed_time = time.time() - start_time\n",
    "print(f\"Milvus search time: {elapsed_time} sec\")\n",
    "\n",
    "# Inspect search result.\n",
    "print(f\"type: {type(results)}, count: {len(results[0])}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "95f7e011",
   "metadata": {},
   "source": [
    "## Assemble and inspect the search result\n",
    "\n",
    "The search result is in the variable `result[0]` of type `'pymilvus.orm.search.SearchResult'`.  "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "22d65363",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Length context: 507, Number of contexts: 3\n",
      "Retrieved result #1\n",
      "Context: Dr. K(David H Hickey)has been trying to master a formula that would end all disease and handicaps, but needs live donors to complete his work. His doc\n",
      "Metadata: {'movie_index': '56', 'label': 'Negative'}\n",
      "\n",
      "Retrieved result #2\n",
      "Context: is not a horror movie, although it does contain some violent scenes, but is rather a comedy. A satire to be precise. And it never runs out of steam! T\n",
      "Metadata: {'movie_index': '44', 'label': 'Positive'}\n",
      "\n",
      "Retrieved result #3\n",
      "Context: a good movie with a real good story. The fact that there are so many other big stars who all also had great performances is just an added BONUS! So do\n",
      "Metadata: {'movie_index': '67', 'label': 'Positive'}\n",
      "\n"
     ]
    }
   ],
   "source": [
    "# Assemble `num_shot_answers` retrieved 1st context and context metadata.\n",
    "METADATA_FIELDS = [f for f in OUTPUT_FIELDS if f != 'chunk']\n",
    "formatted_results, context, context_metadata = _utils.client_assemble_retrieved_context(\n",
    "    results, metadata_fields=METADATA_FIELDS, num_shot_answers=3)\n",
    "print(f\"Length context: {len(context[0])}, Number of contexts: {len(context)}\")\n",
    "\n",
    "# TODO - Uncomment to loop throught each context and metadata and print.\n",
    "for i in range(len(context)):\n",
    "    print(f\"Retrieved result #{i+1}\")\n",
    "    print(f\"Context: {context[i][:150]}\")\n",
    "    print(f\"Metadata: {context_metadata[i]}\")\n",
    "    print()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "309c5109",
   "metadata": {},
   "source": [
    "## Same question, but add Metadata filter.\n",
    "\n",
    "Keeping the same question, add a SQL-like filter on metadata.\n",
    "\n",
    "We expect the same answers as above, but omitting any \"Negative\" labeled movies."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "230c2b44",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Milvus search time: 0.0647287368774414 sec\n",
      "Length context: 457, Number of contexts: 3\n",
      "Retrieved result #1\n",
      "Context: is not a horror movie, although it does contain some violent scenes, but is rather a comedy. A satire to be precise. And it never runs out of steam! T\n",
      "Metadata: {'movie_index': '44', 'label': 'Positive'}\n",
      "\n",
      "Retrieved result #2\n",
      "Context: a good movie with a real good story. The fact that there are so many other big stars who all also had great performances is just an added BONUS! So do\n",
      "Metadata: {'movie_index': '67', 'label': 'Positive'}\n",
      "\n",
      "Retrieved result #3\n",
      "Context: This movie took the Jerry Springer approach to super-human power. \"Wilder Napalm\" is the kind of theme-based movie that I love, addressing the idea th\n",
      "Metadata: {'movie_index': '88', 'label': 'Positive'}\n",
      "\n"
     ]
    }
   ],
   "source": [
    "# Same question, but add Metadata filter only positive movies.\n",
    "metadata_filter = \"(label like 'Positive%')\"\n",
    "\n",
    "# Run the search and time it.\n",
    "start_time = time.time()\n",
    "new_results = mc.search(\n",
    "    COLLECTION_NAME,\n",
    "    data=query_embeddings, \n",
    "    search_params=SEARCH_PARAMS,\n",
    "    output_fields=OUTPUT_FIELDS, \n",
    "    filter=metadata_filter,\n",
    "    limit=TOP_K,\n",
    "    consistency_level=\"Eventually\",\n",
    "    )\n",
    "\n",
    "elapsed_time = time.time() - start_time\n",
    "print(f\"Milvus search time: {elapsed_time} sec\")\n",
    "\n",
    "# Assemble `num_shot_answers` retrieved 1st context and context metadata.\n",
    "METADATA_FIELDS = [f for f in OUTPUT_FIELDS if f != 'chunk']\n",
    "formatted_results, context, context_metadata = _utils.client_assemble_retrieved_context(\n",
    "    new_results, metadata_fields=METADATA_FIELDS, num_shot_answers=3)\n",
    "print(f\"Length context: {len(context[0])}, Number of contexts: {len(context)}\")\n",
    "\n",
    "# TODO - Uncomment to loop throught each context and metadata and print.\n",
    "for i in range(len(context)):\n",
    "    print(f\"Retrieved result #{i+1}\")\n",
    "    print(f\"Context: {context[i][:150]}\")\n",
    "    print(f\"Metadata: {context_metadata[i]}\")\n",
    "    print()\n",
    "\n",
    "# As expected, same answers, except 'Negative' movies are omitted."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9cf49a96",
   "metadata": {},
   "source": [
    "## Try another question\n",
    "\n",
    "This time just add the words **only good movies** to the question, see if the answers are any different?  \n",
    "\n",
    "For semantically different questions, we expect the answers to be different."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "922073f2",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Question: I'm a computer scientist, what movie should I watch?\n",
      "Milvus search time: 0.14014911651611328 sec\n",
      "Length context: 133, Number of contexts: 3\n",
      "Retrieved result #1\n",
      "Context: i would be curious what kids think of this movie. Maybe they would enjoy it? But as for adults, safe bet they wont, even if a CS fan.\n",
      "Metadata: {'movie_index': '37', 'label': 'Negative'}\n",
      "\n",
      "Retrieved result #2\n",
      "Context: Bears about as much resemblance to Dean Koontz's novel as Jessica Simpson does to a rocket scientist. If you've read the book, I suggest you put it as\n",
      "Metadata: {'movie_index': '21', 'label': 'Positive'}\n",
      "\n",
      "Retrieved result #3\n",
      "Context: a good movie with a real good story. The fact that there are so many other big stars who all also had great performances is just an added BONUS! So do\n",
      "Metadata: {'movie_index': '67', 'label': 'Positive'}\n",
      "\n"
     ]
    }
   ],
   "source": [
    "# # Take as input a user question and conduct semantic vector search using the question.\n",
    "question = \"I'm a medical doctor, what movie should I watch?\"\n",
    "new_question = \"I'm a computer scientist, what movie should I watch?\"\n",
    "print(f\"Question: {new_question}\")\n",
    "# Embed the query using same embedding model used to create the Milvus collection.\n",
    "new_query_embeddings = _utils.embed_query(encoder, [new_question])\n",
    "\n",
    "# Run the search and time it.\n",
    "start_time = time.time()\n",
    "new_results = mc.search(\n",
    "    COLLECTION_NAME,\n",
    "    data=new_query_embeddings, \n",
    "    search_params=SEARCH_PARAMS,\n",
    "    output_fields=OUTPUT_FIELDS, \n",
    "    # Milvus can utilize metadata in boolean expressions to filter search.\n",
    "    # expr=\"\",\n",
    "    limit=TOP_K,\n",
    "    consistency_level=\"Eventually\",\n",
    "    )\n",
    "\n",
    "elapsed_time = time.time() - start_time\n",
    "print(f\"Milvus search time: {elapsed_time} sec\")\n",
    "\n",
    "# Assemble `num_shot_answers` retrieved 1st context and context metadata.\n",
    "METADATA_FIELDS = [f for f in OUTPUT_FIELDS if f != 'chunk']\n",
    "formatted_results, context, context_metadata = _utils.client_assemble_retrieved_context(\n",
    "    new_results, metadata_fields=METADATA_FIELDS, num_shot_answers=3)\n",
    "print(f\"Length context: {len(context[0])}, Number of contexts: {len(context)}\")\n",
    "\n",
    "# TODO - Uncomment to loop throught each context and metadata and print.\n",
    "for i in range(len(context)):\n",
    "    print(f\"Retrieved result #{i+1}\")\n",
    "    print(f\"Context: {context[i][:150]}\")\n",
    "    print(f\"Metadata: {context_metadata[i]}\")\n",
    "    print()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "d0e81e68",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Drop collection\n",
    "utility.drop_collection(COLLECTION_NAME)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "c777937e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Author: Christy Bergman\n",
      "\n",
      "Python implementation: CPython\n",
      "Python version       : 3.11.6\n",
      "IPython version      : 8.18.1\n",
      "\n",
      "torch       : 2.1.1\n",
      "transformers: 4.35.2\n",
      "milvus      : 2.3.3\n",
      "pymilvus    : 2.3.4\n",
      "langchain   : 0.0.322\n",
      "\n",
      "conda environment: py311\n",
      "\n"
     ]
    }
   ],
   "source": [
    "# Props to Sebastian Raschka for this handy watermark.\n",
    "# !pip install watermark\n",
    "\n",
    "%load_ext watermark\n",
    "%watermark -a 'Christy Bergman' -v -p torch,transformers,milvus,pymilvus,langchain --conda"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7c5de90f",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
